package com.opennews.openplatform.familyexpenseapi.service

import com.fasterxml.jackson.databind.ObjectMapper
import com.opennews.openplatform.familyexpenseapi.config.AppConfigProperties
import com.opennews.openplatform.familyexpenseapi.entity.Tables.ACCOUNT_GROUP_USER
import com.opennews.openplatform.familyexpenseapi.entity.Tables.USER
import com.opennews.openplatform.familyexpenseapi.jooq.DSLContextWrapper
import com.opennews.openplatform.myspringbootcore.common.constant.*
import com.opennews.openplatform.myspringbootcore.security.FullRole
import com.opennews.openplatform.myspringbootcore.security.MyClaims
import com.opennews.openplatform.myspringbootcore.security.MyUserDetails
import com.opennews.openplatform.myspringbootcore.security.TokenManager
import com.opennews.openplatform.myspringbootcore.util.IdField
import org.jooq.DSLContext
import org.springframework.core.env.Environment
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.*

@Service
class AuthenticationService(
    private val appConfigProperties: AppConfigProperties,
    private val dslContext: DSLContext,
    private val dslContextWrapper: DSLContextWrapper,
    private val env: Environment,
    private val objectMapper: ObjectMapper,
    private val tokenManager: TokenManager,
    private val userService: UserService,
    private val userActiveTokenService: UserActiveTokenService,
) {
    /**
     * Authenticates user based on username and password.
     *
     * @param username    : String of username.
     * @param password    : String of user raw password.
     * @param ipAddress   : String of client request IP address.
     * @param deviceInfo  : Map instance which contains device-related info.
     * @param systemInfo  : The map contains user device system info.
     * @return The map instance which contains keys below.
     * myUserDetails: Instance of MyUserDetails.
     * errorCode: String of server error code.
     */
    fun authenticateByCredential(
        username: String,
        password: String,
        ipAddress: String,
        deviceInfo: Map<String, String>?,
        systemInfo: Map<String, String>?,
    ): Map<String, Any> {
        var myUserDetails = MyUserDetails()
        var errorCode = ""

        if (userService.validatePassword(username, password)) {
            val user = userService.queryStatus(username)

            // Checks if user is not null and handles each status using safe calls
            if (user != null) {
                when {
                    (user["enabled"] as? Boolean) == false -> errorCode =
                        ERROR_USER_ACCOUNT_DISABLED

                    (user["accountExpired"] as? Boolean) == true -> errorCode =
                        ERROR_USER_ACCOUNT_EXPIRED

                    (user["accountLocked"] as? Boolean) == true -> errorCode =
                        ERROR_USER_ACCOUNT_LOCKED

                    (user["passwordExpired"] as? Boolean) == true -> errorCode =
                        ERROR_USER_PASSWORD_EXPIRED

                    else -> {
                        myUserDetails = generateUserDetails(username, deviceInfo?.get("id"))

                        if (!userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo)) {
                            errorCode = ERROR_USER_DEVICE_LOGIN_DISABLED
                        }
                    }
                }
            } else {
                errorCode = ERROR_USER_ACCOUNT_BAD_CREDENTIAL
            }
        } else {
            // If the user input is not correct.
            // We did not give info like 'user not found' or 'password is incorrect'.
            // This is to prevent brute force attacks. If the attacker does not know if the login ID or password is correct,
            // it makes the attack more difficult.
            errorCode = ERROR_USER_ACCOUNT_BAD_CREDENTIAL
        }

        return mapOf(
            "myUserDetails" to myUserDetails,
            "errorCode" to errorCode,
        )
    }

    /**
     * Authenticates user based on username and auth code.
     *
     * @param username   : String of username.
     * @param authCode   : String of auth code.
     * @param deviceInfo : Map instance which contains device-related info.
     * @return The map instance which contains keys below.
     * myUserDetails: Instance of MyUserDetails.
     * errorCode: String of server error code.
     */
    fun authenticateByAuthCode(
        username: String,
        authCode: String,
        deviceInfo: Map<String, String>,
    ): Map<String, Any> {
        val myUserDetails = MyUserDetails()
        val errorCode = ""

        return mapOf(
            "myUserDetails" to myUserDetails,
            "errorCode" to errorCode,
        )
    }

    /**
     * Refreshes access token with valid refresh token.
     *
     * @param refreshToken : String of refresh token.
     * @param ipAddress    : String of client request IP address.
     * @param deviceInfo   : Map instance which contains device-related info.
     * @param systemInfo   : The map contains user device system info.
     * @return The map instance which contains keys below.
     * myUserDetails: Instance of MyUserDetails.
     * errorCode: String of server error code.
     */
    fun refreshToken(
        refreshToken: String,
        ipAddress: String,
        deviceInfo: Map<String, String>?,
        systemInfo: Map<String, String>?,
    ): Map<String, Any> {
        var myUserDetails = MyUserDetails()
        var errorCode = ""

        val parsedToken = tokenManager.validateToken(refreshToken, appConfigProperties.jwt.signingKey!!)

        // If token is valid, check if it is still active.
        if (parsedToken.valid) {
            parsedToken.valid = userActiveTokenService.isActiveRefreshToken(parsedToken.claims!!.username, deviceInfo?.get("id"), refreshToken)
        }

        if (parsedToken.valid) {
            // Generate new details.
            myUserDetails = generateUserDetails(parsedToken.claims!!.username, deviceInfo?.get("id"))

            // Keeps refresh token unchanged.
            myUserDetails.refreshToken = refreshToken

            // Updates new token as active.
            userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo)
        } else {
            errorCode = ERROR_USER_ACCOUNT_NOT_AUTHORIZED
        }

        return mapOf(
            "myUserDetails" to myUserDetails,
            "errorCode" to errorCode,
        )
    }

    fun logout(token: String) = userActiveTokenService.deleteByAccessToken(token)

    /**
     * Switches user to account group by its id.
     *
     * @param accountGroupId String of account group id.
     * @param userId         String of user id.
     * @param username       String of username.
     * @param ipAddress      String of client request IP address.
     * @param deviceInfo     Map instance which contains device related info.
     * @param systemInfo     The map contains user device system info.
     * @return Instance of MyUserDetails.
     */
    fun switchAccountGroup(
        accountGroupId: String,
        userId: String,
        username: String,
        ipAddress: String,
        deviceInfo: Map<String, String>?,
        systemInfo: Map<String, String>?,
    ): MyUserDetails {
        var myUserDetails = MyUserDetails()

        // Checks if user has this account group permission.
        val hasPermission = dslContextWrapper.exists(
            ACCOUNT_GROUP_USER,
            ACCOUNT_GROUP_USER.ACCOUNT_GROUP_ID.eq(accountGroupId)
                .and(ACCOUNT_GROUP_USER.USER_ID.eq(userId))
        )

        if (hasPermission) {
            // Updates user's current account group to the provided one.
            dslContext.update(USER)
                .set(USER.ACCOUNT_GROUP_ID, accountGroupId)
                .where(USER.ID.eq(userId))
                .execute()

            // Generates MyUserDetails and token.
            myUserDetails = generateUserDetails(username, deviceInfo?.get("id"))
            userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo)
        }

        return myUserDetails
    }

    fun generateUserDetails(username: String?, deviceId: String?): MyUserDetails {
        val myUserDetails = MyUserDetails()

        if (!username.isNullOrBlank()) {
            val signingKey = appConfigProperties.jwt.signingKey!!
            val expiration = appConfigProperties.jwt.expiration!!

            // Queries user details from db
            val userDetails = userService.queryDetails(username) ?: return myUserDetails

            // For refresh token which does not need roles and it never expires.
            val claims = MyClaims().apply {
                accountGroupId = IdField.Text(userDetails["accountGroupId"].toString())
                id = IdField.Text(userDetails["id"].toString())
                this.username = username
                this.deviceId = deviceId
            }

            // Generates refresh token. 0 means it never expires.
            val refreshTokenString = tokenManager.generateToken(claims, signingKey, 0)

            // Generates list of security.FullRole based on list of map item.
            val fullRoles = mutableListOf<FullRole>()

            // For claims usage.
            val roleNames = mutableListOf<String>()
            val userDetailsRoles = userDetails["fullRoles"] as List<Map<String, String>>

            for (role in userDetailsRoles) {
                // Converts map item to FullRole.
                val fullRole = objectMapper.convertValue(role, FullRole::class.java)

                // Adds it to role list.
                fullRoles += fullRole

                // Gets role name for claims.
                roleNames += fullRole.authority
            }

            // For access token which will expires in particular duration.
            claims.roles = roleNames

            // Generates access token.
            val accessTokenString = tokenManager.generateToken(claims, signingKey, expiration)

            // Prepares MyUserDetails data.
            myUserDetails.apply {
                accountGroupId = IdField.Text(userDetails["accountGroupId"].toString())
                id = IdField.Text(userDetails["id"].toString())
                this.username = userDetails["username"].toString()
                fullName = userDetails["fullName"].toString()
                avatarUrl = userDetails["avatarUrl"].toString()
                isAccountGroupCreator = userDetails["isAccountGroupCreator"].toString().toBoolean()
                bearer = "bearer"
                roles = claims.roles
                this.fullRoles = fullRoles
                accessToken = accessTokenString
                refreshToken = refreshTokenString
                expiresIn = expiration

                // Without toInstant() the value looks like Thu Apr 25 06:06:52 UTC 2024
                // With toInstant() the value looks like 2024-04-25T06:06:14Z
                // Both works on clientside but toInstant() produces value in China friendly standard UTC format.
                generatedAt = Date.from(Instant.ofEpochSecond(claims.issuedAt!!)).toInstant().toString()
                expiredAt = Date.from(Instant.ofEpochSecond(claims.expiresAt!!)).toInstant().toString()
            }
        }

        return myUserDetails
    }
}